One TurboQuant lookup implementation instead of six - #55
Merged
Conversation
The audit listed this as "the LUT backends are not actually vectorised - each one
gathers four scalar table lookups into a stack array and does a single vector
add". That was accurate, but the conclusion was wrong: there is nothing for SIMD
to do here. The scan is one table lookup per row, NEON has no gather instruction
at all, and measured on real data the loop already runs at about one lookup per
cycle. What the per-backend versions were actually buying was four parallel
float lanes instead of one serial double accumulator - and four independent
double accumulators buy the same parallelism without giving up the accuracy.
So the five copies collapse into one. Plain C, four accumulators, double
throughout. Measured against the NEON version on 4096 distinct rows at dim 768:
bits=2 25.60 -> 26.60 Mvec/s
bits=3 9.48 -> 9.55
bits=4 7.11 -> 7.00
End to end over 40k rows the three bit widths land within noise of where they
were; bits=2 may be a few percent slower, the run-to-run spread is wider than
the difference.
The point is the second number. Every backend now returns the same distance for
the same query: across 300 cases spanning all three bit widths and dimensions 64
to 1536, SIMD versus scalar divergence goes from 1.5e-4 relative to exactly
zero. The old spread came from accumulating in float over up to 384 terms while
the scalar path used double, so which distance you got depended on which CPU ran
the query - enough to reorder near-ties.
A third copy of the same loop lived in sqlite-vector.c as a fallback for a null
dispatch pointer that init_distance_functions() always sets. It is gone too.
Net 197 lines removed. What would actually make this scan faster is a different
storage layout - interleaving codes across vectors so the lookups become
in-register shuffles rather than memory gathers - which changes the on-disk
format and is not this change.
vector_turboquant_backend() keeps returning the same strings; API.md now
describes what it means, which is the SIMD tier selected at load time rather
than a TurboQuant-specific code path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The audit listed this as "the LUT backends are not actually vectorised — each one
gathers four scalar table lookups into a stack array and does a single vector add".
The description was accurate. The conclusion was wrong, and measuring it is what
showed why.
There is nothing here for SIMD to do
The TurboQuant scan is one table lookup per row. NEON has no gather instruction at
all, and on real data the loop already runs at about one lookup per cycle — the
load unit's limit. What the per-backend versions were actually buying was four
parallel
floatlanes instead of one serialdoubleaccumulator.Four independent
doubleaccumulators buy the same parallelism without giving upthe accuracy. Measured against the NEON version over 4096 distinct rows at dim 768:
End to end over 40k rows all three bit widths land within noise of where they were.
bits=2 may be a few percent slower; the run-to-run spread is wider than the
difference, so I would not claim it either way.
The number that matters
Every backend now returns the same distance for the same query. Across 300 cases
spanning all three bit widths and dimensions 64 to 1536, SIMD-versus-scalar
divergence goes from 1.5e-4 relative to exactly zero. The old spread came from
accumulating in
floatover up to 384 terms while the scalar path useddouble,so which distance you got depended on which CPU ran the query — enough to reorder
near-ties.
Five near-identical copies is also what let them drift apart in the first place.
A third copy of the same loop lived in
sqlite-vector.cas a fallback for a nulldispatch pointer that
init_distance_functions()always sets. Gone too.Net 197 lines removed.
What would actually make this faster
A different storage layout — interleaving codes across vectors so the lookups
become in-register shuffles rather than memory gathers, the FastScan approach.
That changes the on-disk format, so it is a product decision rather than a patch.
Worth knowing while that decision is open: with the integer kernels fixed in #54,
TurboQuant is now slower than plain u8 quantization, over 40k rows at dim 768:
TURBO4 stores half the bytes of u8 and runs 9× slower, because it does 384 gathers
into a 393 KB table per row. That is a design trade-off, not a bug, and nothing
here changes it — but the space/time balance is steeper than the docs suggest, and
anyone picking TURBO4 expecting speed as well as compactness is getting the
opposite.
Note on the public API
vector_turboquant_backend()keeps returning exactly the strings it returns today.What changed is that the value no longer identifies a TurboQuant-specific code path,
because there is only one now — it reports the SIMD tier selected at load time, the
same one
vector_backend()reports. API.md now says that. If you would rather dropthe function, that is a separate call and I have not made it here.
Verification
unittest,unittest-simd, and under ASan.🤖 Generated with Claude Code